Contexto del negocio
En este problema, se nos ha presentado una base de datos de 78,829 registros donde se encuentra información de una empresa colombiana que otorga créditos. Esta corresponde a una base de datos histórica durante los últimos 30 meses.
El negocio desea estimar un modelo predictivo que permita estimar la probabilidad de fuga de cada cliente para la cartera de créditos proporcionada. El objetivo es crear un modelo de aprendizaje automático que permita clasificar dicha fuga de clientes para cada registro.
Un modelo de predicción de fuga permitirá a la empresa colombiana, el poder monetizar la predicción obtenida y estimar una posible perdida monetaria en la cartera de créditos. Adicional a esto, se pretende estimar cuales son las variables que mueven esta predicción, de tal manera que el negocio podrá enfocarse en dichos factores con el fin de reducir la perdida en la cartera de crédito.
Posterior al diseño del modelo predictivo, la empresa colombiana esta interesada en segmentar a dichos clientes de acuerdo a probabilidad de fuga, y esto permitirá generar campañas de marketing para fortalecer la relación con los clientes, en la cartera de crédito presente.
Fuente de datos
Se nos ha proporcionado una hoja de datos de excel (formato CSV) el cual tiene el nombre: Base de Datos Modelo.csv (raw data)
Herramientas utilizadas
Se utilizará el lenguaje de programación Python, ya que este cuenta con una amplia gama de paquetes que facilitan la exploración de datos. Adicional a esto, también consta de paquetes (Scikit-Learn) enfocados en machine learning, y para este caso se ponen en marcha algoritmos de clasificación (aprendizaje supervisado).
1.0. Importando paquetes
First we load reticulate package to write Python / R code in our Markdown enviroment:
library(reticulate)
Sys.setenv(RETICULATE_PYTHON = "/usr/local/bin/python3.7")
import pandas as pd # paquete para data wrangling y exploracion
import numpy as np # packate de algebra lineal
import seaborn as sns # interfaz grafica de Python
import matplotlib.pyplot as plt # interfaz grafica de Python
# adicionales:
import copy
import warnings
warnings.filterwarnings('ignore')
# paquete para aplicar clustering:
from sklearn.cluster import KMeans
pd.set_option('display.max_columns', 35)
pd.options.display.float_format = '{:.2f}'.format
2.0. Carga de datos
df = pd.read_excel('/home/analytics/R/Projects/Python/Projects/genesis/Base de Datos Modelo.xlsx')
df.head()
## CODIGO CLIENTE CODIGO PRESTAMO REGION AGENCIA PRODUCTO SUBPRODUCTO \
## 0 36253 362530 11 38 44 9
## 1 36569 365690 11 38 44 9
## 2 32082 320820 5 115 43 1
## 3 59344 593440 5 115 40 1
## 4 78551 785510 15 100 57 1
##
## TIPO DE CREDITO TASA_NOMINAL SEXO \
## 0 C 55.50 F
## 1 C 55.50 F
## 2 C ... 55.50 F
## 3 C ... 55.50 F
## 4 D 43.60 F
##
## CAPITAL_CONCEDIDO SALDO_CAPITAL ETAPA CREDITOS ANTERIORES \
## 0 1200.00 236.58 M1 2
## 1 1200.00 236.58 M1 2
## 2 1200.00 247.98 M1 1
## 3 1200.00 248.04 M1 2
## 4 1200.00 254.67 M1 2
##
## ESTADO
## 0 Cliente Renovado
## 1 Cliente Renovado
## 2 Cliente Retirado
## 3 Cliente Renovado
## 4 Cliente Renovado
Despliegue de dimension:
print(df.shape)
## (78829, 14)
Tipos de datos cargados:
df.info()
## <class 'pandas.core.frame.DataFrame'>
## RangeIndex: 78829 entries, 0 to 78828
## Data columns (total 14 columns):
## # Column Non-Null Count Dtype
## --- ------ -------------- -----
## 0 CODIGO CLIENTE 78829 non-null int64
## 1 CODIGO PRESTAMO 78829 non-null int64
## 2 REGION 78829 non-null int64
## 3 AGENCIA 78829 non-null int64
## 4 PRODUCTO 78829 non-null int64
## 5 SUBPRODUCTO 78829 non-null int64
## 6 TIPO DE CREDITO 78829 non-null object
## 7 TASA_NOMINAL 78829 non-null float64
## 8 SEXO 78829 non-null object
## 9 CAPITAL_CONCEDIDO 78829 non-null float64
## 10 SALDO_CAPITAL 78829 non-null float64
## 11 ETAPA 78829 non-null object
## 12 CREDITOS ANTERIORES 78829 non-null int64
## 13 ESTADO 78829 non-null object
## dtypes: float64(3), int64(7), object(4)
## memory usage: 8.4+ MB
Despliegue de variables numericas
df.describe() #variables numericas
## CODIGO CLIENTE CODIGO PRESTAMO REGION AGENCIA PRODUCTO \
## count 78829.00 78829.00 78829.00 78829.00 78829.00
## mean 44701.61 447016.14 7.68 58.14 42.43
## std 25598.41 255984.06 4.51 66.30 5.26
## min 1.00 10.00 1.00 1.00 21.00
## 25% 22630.00 226300.00 4.00 29.00 40.00
## 50% 44863.00 448630.00 7.00 53.00 41.00
## 75% 66832.00 668320.00 11.00 79.00 44.00
## max 88828.00 888280.00 33.00 871.00 90.00
##
## SUBPRODUCTO TASA_NOMINAL CAPITAL_CONCEDIDO SALDO_CAPITAL \
## count 78829.00 78829.00 78829.00 78829.00
## mean 3.71 41.05 11582.48 4515.81
## std 4.46 9.81 15437.91 9448.51
## min 1.00 0.00 1200.00 0.00
## 25% 1.00 30.00 3000.00 507.53
## 50% 2.00 43.60 6000.00 1229.26
## 75% 6.00 43.60 15000.00 3815.11
## max 52.00 60.50 600000.00 294137.83
##
## CREDITOS ANTERIORES
## count 78829.00
## mean 3.45
## std 3.81
## min 1.00
## 25% 2.00
## 50% 3.00
## 75% 4.00
## max 106.00
No existen duplicados en los datos:
df[df.duplicated()] #no existen datos duplicados:
## Empty DataFrame
## Columns: [CODIGO CLIENTE, CODIGO PRESTAMO, REGION, AGENCIA, PRODUCTO, SUBPRODUCTO, TIPO DE CREDITO, TASA_NOMINAL, SEXO, CAPITAL_CONCEDIDO, SALDO_CAPITAL, ETAPA, CREDITOS ANTERIORES, ESTADO]
## Index: []
3.0. Data wrangling / Limpieza de datos
EXPLORACION DE VARIABLES CATEGORICAS
TIPO DE CREDITO:
df['TIPO DE CREDITO'] = df['TIPO DE CREDITO'].str.replace('[^A-Za-z0-9]+', '', regex=True)
100 * df['TIPO DE CREDITO'].value_counts() / df.shape[0]
## A 48.33
## C 26.97
## V 13.17
## D 6.19
## F 2.82
## E 1.62
## B 0.87
## G 0.04
## f 0.00
## Name: TIPO DE CREDITO, dtype: float64
Se puede ver que la mayoría de tipos de crédito corresponde a A, C y V. Como buena práctica (y no perjudicar los modelos analitos) agruparemos las demás categorías como “Otros”
def mapper(x):
if x in ['A', 'C', 'V']:
return x
else:
return 'Other'
df['TIPO DE CREDITO'] = df['TIPO DE CREDITO'].map(mapper)
df['TIPO DE CREDITO'].value_counts()
## A 38099
## C 21260
## V 10378
## Other 9092
## Name: TIPO DE CREDITO, dtype: int64
SEXO
#SEXO
df['SEXO'].value_counts()
# tenemos un valor en donde no se especifica el sexo:
## F 54654
## M 24174
## 1
## Name: SEXO, dtype: int64
df['SEXO'] = df['SEXO'].str.replace('[^A-Za-z0-9]+', 'invalido', regex=True)
ETAPA
df['ETAPA'].value_counts()
## M1 70530
## M2 4966
## M3 3333
## Name: ETAPA, dtype: int64
ESTADO
df['ESTADO'].value_counts()
## Cliente Renovado 48815
## Cliente Retirado 30014
## Name: ESTADO, dtype: int64
to_dummy = ['ETAPA', 'SEXO', 'TIPO DE CREDITO']
df['ESTADO'] = ['1' if x == "Cliente Retirado" else '0' for x in df['ESTADO'] ]
class NANS:
def __init__(self, data):
self._data = data
def tot_nan(self):
nulls_v = self._data.isnull().sum(axis = 1) >= 1
#self._data['NA Flag'] = nulls_v.rename('NA Flag')
df = pd.DataFrame((self._data.isna().sum())).reset_index().\
merge( (100 * (self._data.isna().sum()) / self._data.shape[0]).round(3).\
reset_index(), on = 'index').rename(columns = {'index': 'Feature', '0_x': 'Count', '0_y':'%'}).\
sort_values( by = ['%'], ascending = False)
df = df.merge(pd.DataFrame(self._data.dtypes).reset_index().rename(\
columns = {'index':'Feature', 0:'Var Type'}), on = 'Feature')
return df.reset_index().drop(["index"], axis=1)
nulls_df = NANS(df)
nulls_df.tot_nan()
# NO TENEMOS VALORES NULOS:
## Feature Count % Var Type
## 0 CODIGO CLIENTE 0 0.00 int64
## 1 CODIGO PRESTAMO 0 0.00 int64
## 2 REGION 0 0.00 int64
## 3 AGENCIA 0 0.00 int64
## 4 PRODUCTO 0 0.00 int64
## 5 SUBPRODUCTO 0 0.00 int64
## 6 TIPO DE CREDITO 0 0.00 object
## 7 TASA_NOMINAL 0 0.00 float64
## 8 SEXO 0 0.00 object
## 9 CAPITAL_CONCEDIDO 0 0.00 float64
## 10 SALDO_CAPITAL 0 0.00 float64
## 11 ETAPA 0 0.00 object
## 12 CREDITOS ANTERIORES 0 0.00 int64
## 13 ESTADO 0 0.00 object
class features:
def __init__(self, data_f):
self._df = data_f
def select_if(self, x):
if x == 'is.numeric':
return self._df[self._df.select_dtypes(include = 'number' ).columns]
elif x == 'is.character':
return self._df[self._df.select_dtypes(exclude = 'number' ).columns]
else:
raise ValueError('Invalid value. Please provide: "is.numeric" or "is.character" only')
# distribucion de features
feats = features(df)
num_feats = feats.select_if('is.numeric')
cat_feats = feats.select_if('is.character')
# guardamos el dataframe en nueva variable: train
import copy
train = copy.deepcopy(df)
4.0. Analisis Exploratorio de Datos (EDA)
Exploracion de variables numericas: (boxplots)
fig = plt.figure(figsize=(20,20))
for index, item in enumerate(num_feats.columns, 1):
plt.subplot(4, 3, index)
sns.boxplot(y=train[item], x= train['ESTADO'] , hue= train['ESTADO'],
linewidth=2.5)
plt.legend()
plt.show()
Exploracion de variables numericas: (densities)
fig = plt.figure(figsize=(20,20))
for index, item in enumerate(num_feats.columns, 1):
plt.subplot(4, 3, index)
sns.distplot(train[train.ESTADO == '1'][item], color="red", hist = False, kde=True, norm_hist=True)
sns.distplot(train[train.ESTADO == '0'][item], color="blue", hist = False, kde=True, norm_hist=True)
plt.legend(labels=['Si','No'], title = 'Fuga??')
plt.show()
Exploracion de variables numericas: (cummulative densities)
fig = plt.figure(figsize=(20,20))
for index, item in enumerate(num_feats.columns, 1):
plt.subplot(4, 3, index)
sns.kdeplot(train[train.ESTADO == '1'][item], color="red", cumulative = True)
sns.kdeplot(train[train.ESTADO == '0'][item], color="blue", cumulative = True)
plt.legend(labels=['Yes','No'], title = 'Fuga?')
plt.show()
EXPLORACION DE CORRELACION: VARIABLES NUMERICAS
num_feats.corr()
## CODIGO CLIENTE CODIGO PRESTAMO REGION AGENCIA \
## CODIGO CLIENTE 1.00 1.00 -0.09 -0.07
## CODIGO PRESTAMO 1.00 1.00 -0.09 -0.07
## REGION -0.09 -0.09 1.00 0.37
## AGENCIA -0.07 -0.07 0.37 1.00
## PRODUCTO 0.09 0.09 -0.07 -0.23
## SUBPRODUCTO 0.07 0.07 0.07 0.17
## TASA_NOMINAL -0.09 -0.09 -0.06 -0.03
## CAPITAL_CONCEDIDO 0.15 0.15 -0.01 -0.05
## SALDO_CAPITAL 0.25 0.25 -0.04 -0.03
## CREDITOS ANTERIORES 0.20 0.20 -0.12 -0.05
##
## PRODUCTO SUBPRODUCTO TASA_NOMINAL CAPITAL_CONCEDIDO \
## CODIGO CLIENTE 0.09 0.07 -0.09 0.15
## CODIGO PRESTAMO 0.09 0.07 -0.09 0.15
## REGION -0.07 0.07 -0.06 -0.01
## AGENCIA -0.23 0.17 -0.03 -0.05
## PRODUCTO 1.00 -0.12 0.20 -0.03
## SUBPRODUCTO -0.12 1.00 -0.07 0.04
## TASA_NOMINAL 0.20 -0.07 1.00 -0.42
## CAPITAL_CONCEDIDO -0.03 0.04 -0.42 1.00
## SALDO_CAPITAL 0.05 0.11 -0.33 0.79
## CREDITOS ANTERIORES 0.17 0.13 -0.25 0.20
##
## SALDO_CAPITAL CREDITOS ANTERIORES
## CODIGO CLIENTE 0.25 0.20
## CODIGO PRESTAMO 0.25 0.20
## REGION -0.04 -0.12
## AGENCIA -0.03 -0.05
## PRODUCTO 0.05 0.17
## SUBPRODUCTO 0.11 0.13
## TASA_NOMINAL -0.33 -0.25
## CAPITAL_CONCEDIDO 0.79 0.20
## SALDO_CAPITAL 1.00 0.24
## CREDITOS ANTERIORES 0.24 1.00
f, ax = plt.subplots(figsize = (18,18))
sns.heatmap(num_feats.corr(), annot = True, linewidths=0.5, fmt = '.1f', ax = ax)
plt.show()
Se observa una correlación muy fuerte (0.8) entre CAPITAL_CONCEDIDO y SALDO_CAPITAL, esto se tendrá en consideración durante el desarrollo del modelo. Por otro lado, CODIGO_PRESTAMO y CODIGO_PRESTAMO no aportan valor al modelo, y se puede ver que están altamente correlacionadas.
EXPLORACION DE VARIABLES CATEGORICAS
def plot_cat(cats = to_dummy, a = 1, b = 1, c = 1 ):
fig = plt.figure(figsize=(15,20))
for i in cats:
grouped_df = train[['ESTADO', i ]] .groupby(['ESTADO', i]).size().to_frame('Percent')
grouped_df['Percent'] = (grouped_df['Percent'] * 100 / sum(grouped_df['Percent'])).round(0)
grouped_df = grouped_df.reset_index()
plt.subplot(a, b, c)
plt.title('{}, subplot: {}{}{}'.format(i, a, b, c))
plt.xlabel(i)
sns.barplot(x=i, y = 'Percent', hue='ESTADO', data=grouped_df)
plt.legend(title = 'Fuga = 1')
c = c + 1
plt.show()
plot_cat(cats = to_dummy, a = 2, b = 2, c = 1)
4.1. INSIGHTS ENCONTRADOS
1. Tanto la variable CODIGO CLIENTE y CODIGO PRESTAMO no son significativas en el modelo, puesto que solamente corresponde a IDs únicos o llaves por cada cliente.
2. Dentro de la variable REGION se pueden encontrar un grupo de 0 – 5, se puede distinguir un grupo significativo de clientes que se dan a la fuga. No parece existir fuga persistente entre la región 16 y 30, sin embargo, se observan muchos valores atípicos arriba de 30 (tanto para clientes leales como clientes fugados)
3. Para la variable AGENCIA, existe sesgo a la derecha de clientes con fuga positiva, especialmente para los casos arriba 200. ¿Por qué la fidelidad de clientes no es muy remarcada en esta zona? Convendría investigar este hecho.
4. Conviene revisar los clientes fugados entre PRODUCTO = 20 a PRODUCTO = 30, ya que en dicha región no existen registros de clientes fieles a nuestra marca.
5. La mayor parte de créditos se concentra dentro de la TASA NOMINAL 40 y 50. Esto, para ambos grupos de fuga y no fuga.
6. La mayor preferencia respecto al CAPITAL CONCEDIDO se encuentra debajo de Q. 20,000; la cual afecta de manera significativa a ambos grupos fuga y no fuga.
7. La mayor parte del SALDO CAPITAL se mantiene debajo de Q5,000. Sin embargo, existe una diferencia significativa y pronunciada para aquellos que tienen a fugarse entre 0 y Q.4,000.
8. De acuerdo al histórico de CREDITOS ANTERIORES, la mayoría de clientes mantiene estos debajo de 5.
9. Existe una mayor presencia (60% de clientes no fugados y 30% fugados) en aquellos créditos cuya ETAPA es M1. Las etapas M2 y M3 no sobrepasan en 5% de casos, pero cabe destacar que solo se han registrado fuga de clientes en la categoría M3 (no existen clientes fieles).
10. La mayor parte de créditos se otorga a personas de sexo femenino y en general, las mujeres tienen a ser más fieles a nuestra marca, comparado con los hombres. Existe mayor oportunidad de negocio en los clientes con crédito TIPO A, registrando un total de 32% de casos, que son fieles a nuestra marca.
5.0. FEATURE ENGINEERING / DATA PREPARATION
De acuerdo a nuestro EDA, eliminamos variables que no aportan valor al modelo de prediccion de fuga. Por el momento, eliminamos CODIGO CLIENTE y CODIGO PRESTAMO
train.drop(['CODIGO CLIENTE', 'CODIGO PRESTAMO'], axis = 1, inplace = True)
train.head()
## REGION AGENCIA PRODUCTO SUBPRODUCTO TIPO DE CREDITO TASA_NOMINAL SEXO \
## 0 11 38 44 9 C 55.50 F
## 1 11 38 44 9 C 55.50 F
## 2 5 115 43 1 C 55.50 F
## 3 5 115 40 1 C 55.50 F
## 4 15 100 57 1 Other 43.60 F
##
## CAPITAL_CONCEDIDO SALDO_CAPITAL ETAPA CREDITOS ANTERIORES ESTADO
## 0 1200.00 236.58 M1 2 0
## 1 1200.00 236.58 M1 2 0
## 2 1200.00 247.98 M1 1 1
## 3 1200.00 248.04 M1 2 0
## 4 1200.00 254.67 M1 2 0
Para un modelo de regresión logística (como se ve más adelante), se requiere que las variables o features no posean mucha varianza (como se pudo ver en el EDA existe muchas de ellas con variables atípicos). Para esto se procede suavizar las variables mediante la aplicación de un logaritmo natural, así como también la normalización estándar.
feats = features(train)
num_feats = feats.select_if('is.numeric')
for column in num_feats:
try:
train[column] = np.log1p(train[column])
except (ValueError, AttributeError):
pass
train.head()
## REGION AGENCIA PRODUCTO SUBPRODUCTO TIPO DE CREDITO TASA_NOMINAL SEXO \
## 0 2.48 3.66 3.81 2.30 C 4.03 F
## 1 2.48 3.66 3.81 2.30 C 4.03 F
## 2 1.79 4.75 3.78 0.69 C 4.03 F
## 3 1.79 4.75 3.71 0.69 C 4.03 F
## 4 2.77 4.62 4.06 0.69 Other 3.80 F
##
## CAPITAL_CONCEDIDO SALDO_CAPITAL ETAPA CREDITOS ANTERIORES ESTADO
## 0 7.09 5.47 M1 1.10 0
## 1 7.09 5.47 M1 1.10 0
## 2 7.09 5.52 M1 0.69 1
## 3 7.09 5.52 M1 1.10 0
## 4 7.09 5.54 M1 1.10 0
CODIFICACION DE VARIABLES CATEGORICAS A DUMMY
dummies = pd.get_dummies(train[cat_feats.columns], drop_first = True)
train_dummies = pd.concat([train, dummies], axis = 1)
train_dummies.drop(cat_feats.columns, axis = 1, inplace = True)
train_dummies.head()
## REGION AGENCIA PRODUCTO SUBPRODUCTO TASA_NOMINAL CAPITAL_CONCEDIDO \
## 0 2.48 3.66 3.81 2.30 4.03 7.09
## 1 2.48 3.66 3.81 2.30 4.03 7.09
## 2 1.79 4.75 3.78 0.69 4.03 7.09
## 3 1.79 4.75 3.71 0.69 4.03 7.09
## 4 2.77 4.62 4.06 0.69 3.80 7.09
##
## SALDO_CAPITAL CREDITOS ANTERIORES TIPO DE CREDITO_C \
## 0 5.47 1.10 1
## 1 5.47 1.10 1
## 2 5.52 0.69 1
## 3 5.52 1.10 1
## 4 5.54 1.10 0
##
## TIPO DE CREDITO_Other TIPO DE CREDITO_V SEXO_M SEXO_invalido ETAPA_M2 \
## 0 0 0 0 0 0
## 1 0 0 0 0 0
## 2 0 0 0 0 0
## 3 0 0 0 0 0
## 4 1 0 0 0 0
##
## ETAPA_M3 ESTADO_1
## 0 0 0
## 1 0 0
## 2 0 1
## 3 0 0
## 4 0 0
NORMALIZACION DE VARIABLES NUMERICAS
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
columns_std = num_feats.columns
train_dummies[columns_std] = scaler.fit_transform(train_dummies[columns_std])
X = train_dummies.drop('ESTADO_1', axis=1)
y = train_dummies['ESTADO_1']
6.0. CONSTRUCCION DE MODELOS
Training / Testing Split (75% para train y 25% para validation)
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.25, random_state=42)
CHEQUEO DE VARIABLE TARGET (DATOS BALANCEADOS O IMBALANCEADOS)
100 * y_train.value_counts() / len(y_train)
## 0 61.95
## 1 38.05
## Name: ESTADO_1, dtype: float64
Podemos confirmar que nuestros datos estan imbalanceados, por lo que necesitaremos el ajuste de class_weight para dar mayor peso a la categoria minoritaria (fuga de cliente, que equivale a 1).
6.1. REGRESION LOGISTICA (SIN REGULARIZACION)
from sklearn.metrics import classification_report, roc_auc_score, f1_score, precision_score, recall_score, auc, precision_recall_curve, roc_curve, confusion_matrix, make_scorer
from sklearn.metrics import precision_recall_fscore_support
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV, StratifiedKFold, RepeatedStratifiedKFold
NOTA: EL SIGUIENTE SET DE CODIGO NO SE EJECUTA, YA QUE ANTERIORMENTE SE HA PROCEDIDO A REALIZAR UN GRIDSEARCH PARA CONFIGURACION DE HIPERPRAMETROS. EL OBJETO GRIDSEARCH FUE GUARDADO EN UN ARCHIVO QUE POSTERIORMENTE SE HA CARGADO
## 1. Creacion de objeto log_reg y aplicar metodo LogisticRegression:
#log_reg = LogisticRegression()
## 2. Configuracion de solvers y weight class (tratar con clases imbalanceadas)
#solvers = ['newton-cg', 'lbfgs', 'liblinear', 'lbfgs'] # escoger el mejor solver
#weights = [{0:x, 1:1.0-x} for x in np.linspace(0.0,0.99,100)] # pesos
## 3. Almacenar hiperparametros en diccionario:
#param_grid = dict(solver = solvers,
# class_weight = weights)
## 4. Seleccionamos metricas para estimar accuracy: Precision, Recall y F1 Score
#scorers = {
# 'precision_score': make_scorer(precision_score),
# 'recall_score': make_scorer(recall_score),
# 'f1_score': make_scorer(f1_score)
#}
## 5. HYPER PARAMETER TUNNING
#gridsearch = GridSearchCV(estimator= log_reg,
# param_grid= param_grid,
# cv=StratifiedKFold(n_splits = 10),
# n_jobs=-1,
# scoring=scorers,
# refit= 'f1_score', #we focus on the F1 metric to display the better results
# return_train_score=True,
# verbose=2).fit(X_train.values, y_train.values)
Fitting 10 folds for each of 400 candidates, totalling 4000 fits
import dill
# Save the file
#dill.dump(gridsearch, file = open("/home/analytics/R/Projects/Python/Projects/genesis/gridsearch.pickle", "wb"))
# Reload the file
gridsearch = dill.load(open("/home/analytics/R/Projects/Python/Projects/genesis/gridsearch.pickle", "rb"))
Despliegue de resultados: Logistic Regression:
y_pred = gridsearch.predict(X_test.values)
print('Best params for F1 score')
## Best params for F1 score
print(gridsearch.best_params_) # mejores parametros escogidos para solver y class_weight (segun metrica F1)
## {'class_weight': {0: 0.35000000000000003, 1: 0.6499999999999999}, 'solver': 'liblinear'}
CONFUSION MATRIX
def conf_matrix(y_test, log_reg_pred):
# Creating a confusion matrix
con_mat = confusion_matrix(y_true=y_test, y_pred=log_reg_pred)
con_mat = pd.DataFrame(con_mat, range(2), range(2))
#Ploting the confusion matrix
plt.figure(figsize=(6,6))
sns.set(font_scale=1.5)
sns.heatmap(con_mat, annot=True, annot_kws={"size": 16}, fmt='g', cmap='Blues', cbar=False)
# axis labels
plt.xlabel('Predictions')
plt.ylabel('Actuals')
title = 'Confusion Matrix'.upper()
plt.title(title, loc='center')
conf_matrix(y_test, y_pred)
plt.show()
Se puede observar que todavía existe espacio para mejora. Para los casos actuales de fuga, los valores pronosticados contienen 5,829 casos correctos, pero 1,689 casos incorrectos. Y para los casos donde los clientes son fieles (no fuga), se tiene un error de 1,394
REPORTE PRECISION AND RECALL:
precision_recall_fscore_support(y_test, y_pred, average='macro')
# PRECION, RECALL, F1:
## (0.8388382707286938, 0.8523752346296698, 0.8432369566375288, None)
PRECISION: El falso positivo es un caso problemático para la institución colombiana. Ya que si un cliente que se encontraba en estado NO fugado y se ha pronosticado que SI se ha fugado, puede que incurra en problemas internos en ofrecer campañas innecesarias. Sin embargo, esto no es tan CRITICO como el falso negativo.
RECALL: Hace énfasis en capturar los POSITIVOS REALES y también da prioridad al falso negativo. Para la institución colombiana, es MUY CRITICO indicar que un cliente el cual se encontraba en ESTADO FUGADO, se ha pronosticado como un cliente no fugado. Esto representa una perdida monetaria a la institución, ya que esta dejando de monitorear aquellos casos positivos (fuga) y esto incurre en un costo para ella.
def plot_precision_recall_vs_threshold(precisions, recalls, thresholds):
plt.figure(figsize=(8, 8))
plt.title("Precision / Recall Scores (decision threshold)")
plt.plot(thresholds, precisions[:-1], "b--", label="Precision")
plt.plot(thresholds, recalls[:-1], "g-", label="Recall")
plt.ylabel("Score")
plt.xlabel("Decision Threshold")
plt.legend(loc='best')
y_scores = gridsearch.predict_proba(X_test)[:, 1]
p, r, thresholds = precision_recall_curve(y_test, y_scores)
plot_precision_recall_vs_threshold(p, r, thresholds)
plt.show()
Eligiendo un THRESHOLD O UMBRAL de aproximadamente 42% se obtiene un PRECISION y RECALL de aproximadamente 80%. Este umbral lo que indica, es que la probabilidad arriba de 42% se considera para el caso de un cliente FUGADO (1) y debajo de 42%, significa cliente NO FUGADO (0)
Eligiendo un umbral por debajo de 42% se obtiene beneficio con el RECALL. Por ejemplo, a un THRESHOLD de 40% se alcanza un RECALL de aproximadamente 83%, pero esto a costa de reducir la PRECISION a 77% aproximadamente.
6.2. MODELO RANDOM FOREST
CONFIGURACION DE HIPERPARAMETROS
from sklearn.ensemble import RandomForestClassifier
# PARA EL ALGORITMO DE RANDOM FOREST, NO ES NECESARIO NORMALIZAR LAS VARIABLES
# NUMERICAS.
dummies = pd.get_dummies(df[cat_feats.columns], drop_first = True)
train_dummies = pd.concat([df, dummies], axis = 1)
train_dummies.drop(cat_feats.columns, axis = 1, inplace = True)
X = train_dummies.drop(['ESTADO_1', 'CODIGO CLIENTE', 'CODIGO PRESTAMO'], axis=1)
y = train_dummies['ESTADO_1']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.25, random_state=42)
## 1. Creacion de objeto rand_forest y aplicar metodo RandomForestClassifier:
#rand_forest = RandomForestClassifier(class_weight = {0: 0.35000000000000003, 1: 0.6499999999999999}, max_depth = 20)
## 2. Numero de arboles a crecer:
#n_estimators = [int(x) for x in np.linspace(start = 100, stop = 500, num = 21)]
## 3. Nivel maximo en cada arbol
#max_depth = [int(x) for x in np.linspace(10, 25 , num = 12)]
#random_grid = {'n_estimators': n_estimators }
## 4. Seleccionamos metricas para estimar accuracy: Precision, Recall y F1 Score
#scorers = {
# 'precision_score': make_scorer(precision_score),
# 'recall_score': make_scorer(recall_score),
# 'f1_score': make_scorer(f1_score)
#}
## 5. HYPER PARAMETER TUNNING
#gridsearch = GridSearchCV(estimator= rand_forest,
# param_grid= random_grid,
# cv=StratifiedKFold(n_splits = 10),
# n_jobs=-1,
# scoring=scorers,
# refit= 'f1_score', #we focus on the F1 metric to display the better results
# return_train_score=True,
# verbose=2).fit(X_train.values, y_train.values)
## GUARDAR MODELO
import dill
# Save the file
#dill.dump(gridsearch, file = open("/home/analytics/R/Projects/Python/Projects/genesis/gridsearch_3.pickle", "wb"))
# Reload the file
gridsearch = dill.load(open("/home/analytics/R/Projects/Python/Projects/genesis/gridsearch_3.pickle", "rb"))
y_pred = gridsearch.predict(X_test.values)
print('Best params for F1 score')
## Best params for F1 score
print(gridsearch.best_params_) # mejores parametros escogidos
## {'n_estimators': 300}
RECALIBRANDO EL MODELO
random_forest = RandomForestClassifier(class_weight = {0: 0.35000000000000003, 1: 0.6499999999999999}, max_depth = 20, n_estimators= 300, random_state = 123)
random_forest.fit(X_train, y_train)
## RandomForestClassifier(class_weight={0: 0.35000000000000003,
## 1: 0.6499999999999999},
## max_depth=20, n_estimators=300, random_state=123)
y_pred = random_forest.predict(X_test.values)
conf_matrix(y_test, y_pred)
plt.show()
precision_recall_fscore_support(y_test, y_pred, average='macro')
# PRECION, RECALL, F1:
## (0.895411817191262, 0.9028013598645722, 0.8986818222914237, None)
y_scores = random_forest.predict_proba(X_test)[:, 1]
p, r, thresholds = precision_recall_curve(y_test, y_scores)
plot_precision_recall_vs_threshold(p, r, thresholds)
plt.show()
results = pd.DataFrame({'precision': p[:-1], 'recall': r[:-1], 'th':thresholds }, columns=['precision', 'recall', 'th'])
results[(results.th >= 0.55) & (results.th <= 0.60) ]
## precision recall th
## 9814 0.87 0.88 0.55
## 9815 0.87 0.88 0.55
## 9816 0.87 0.88 0.55
## 9817 0.87 0.88 0.55
## 9818 0.87 0.88 0.55
## ... ... ... ...
## 10139 0.89 0.86 0.60
## 10140 0.88 0.86 0.60
## 10141 0.89 0.86 0.60
## 10142 0.89 0.86 0.60
## 10143 0.89 0.86 0.60
##
## [330 rows x 3 columns]
El modelo muestra una mejora respecto al equilibrio entre la PRECISION Y RECALL, empleando un THRESHOLD de aproximadamente 58%. La gran ventaja de este modelo es que podemos lograr obtener un RECALL de 85%, empleando un THRESHOLD de 70% y sumado a esto, NO ponemos en riesgo de la PRECISION, ya que esta queda a un nivel de 80%.
El THRESHOLD optimo seleccionado sera:
precision = 0.89, recall = 0.85, threshold = 0.62
- Dado los dos modelos anteriores, el modelo de Random Forest sobre pasa en performance al modelo de Regresión Logística. Se pudo observar que es posible seleccionar un threshold de tal manera de no impactar en la PRECISION y el RECALL, considerando que esta última es crítica para un modelo de fuga.
- En términos de interpretabilidad, el modelo de regresión logística es más comprensible el poder explicarlo a una audiencia sin conocimientos técnicos y matemáticos. El algoritmo de random forest consta de una estructura de árboles de decisión, y debido a la complejidad matemática, puede ser difícil destapar la caja negra.
- Una desventaja de la regresión logística es que la relación de las variables predictoras y el predictor (fuga) debe ser lineal. Convendría hacer un estudio, del poder incluir variables adicionales que puedan mejorar su performance. Sin embargo, si lo que se requiere es precisión, se recomienda utilizar el modelo de random forest.
7.0. IMPORTANCIA DE VARIABLES
Para evaluar la importancia de variables, recurrimos a la métrica de Gini o la mejora de impureza (promedio):
random_forest.fit(X_train, y_train)
## RandomForestClassifier(class_weight={0: 0.35000000000000003,
## 1: 0.6499999999999999},
## max_depth=20, n_estimators=300, random_state=123)
std = np.std([tree.feature_importances_ for tree in random_forest.estimators_], axis=0)
importances = random_forest.feature_importances_
forest_importances = pd.Series(importances, index = X_train.columns)
fig, ax = plt.subplots()
forest_importances.plot.barh(yerr=std, ax=ax)
ax.set_title("Importancia de variables")
ax.set_ylabel("Mejora en nivel de impureza (media) - Gini")
plt.show()
Como se puede observar, las variables “CREDITOS ANTERIORES” y “SALDO CAPITAL” son las variables mas relevantes en el sistema de predicción de fuga de clientes. El “CAPITAL CONCEDIDO” queda en tercer lugar.
8.0. PREDICCION DE DATOS (HOJA DE CALCULO TEST)
CARGA DE DATOS
test_df = pd.read_excel('/home/analytics/R/Projects/Python/Projects/genesis/Base de Datos Predicción.xlsx')
CONVERSION DE VARIABLES EN NUEVO DATAFRAME:
test_df['TIPO DE CREDITO'] = test_df['TIPO DE CREDITO'].str.replace('[^A-Za-z0-9]+', '', regex=True)
test_df['TIPO DE CREDITO'] = test_df['TIPO DE CREDITO'].map(mapper)
test_df['SEXO'] = test_df['SEXO'].str.replace('[^A-Za-z0-9]+', 'invalido', regex=True)
vars_valid = ['TIPO DE CREDITO', 'SEXO', 'ETAPA']
dummies = pd.get_dummies(test_df[vars_valid], drop_first = True)
val_dummies = pd.concat([test_df, dummies], axis = 1)
val_dummies.drop(vars_valid, axis = 1, inplace = True)
X_val = val_dummies.drop(['CODIGO CLIENTE', 'CODIGO PRESTAMO'], axis=1)
X_val.drop(['Probabilidad Fuga', 'Predicción '], axis = 1, inplace = True)
X_val['SEXO_invalido'] = "0"
X_val.SEXO_invalido = X_val.SEXO_invalido.astype('uint8')
PREDICCION DE ESTADO:
y_pred_val = random_forest.predict(X_val.values)
PREDICCION DE PROBABILIDADES:
y_scores_val = random_forest.predict_proba(X_val)[:, 1]
output = pd.read_excel('/home/analytics/R/Projects/Python/Projects/genesis/Base de Datos Predicción.xlsx')
output['Probabilidad Fuga'] = y_scores_val
output['Predicción '] = ['Cliente Retirado' if x >= 0.62 else 'Cliente Renovado' for x in output['Probabilidad Fuga'] ]
EXPORTANDO EL ARCHIVO
import pandas as pd
import openpyxl
#output.to_excel('/home/analytics/R/Projects/Python/Projects/genesis/Base de Datos Modelo (salida).xlsx', sheet_name='salida')
8.0. ANALISIS DE CLUSTERING
Con las 3 variables de importancia que fueron obtenidos con el algoritmo de Random Forest, se procede a seleccionar las variables: ‘CAPITAL_CONCEDIDO’, ‘SALDO_CAPITAL’, ‘CREDITOS ANTERIORES’ y ‘Probabilidad de Fuga’. Posteriormente se puede a tratar de construir un modelo de segmentación utilizando K-Means Clustering:
Carga de datos:
cluster = dill.load(open("/home/analytics/R/Projects/Python/Projects/genesis/cluster.pickle", "rb"))
cluster_new = cluster.drop(['CODIGO CLIENTE', 'ETAPA_M2',
'AGENCIA', 'SUBPRODUCTO', 'REGION', 'PRODUCTO', 'TASA_NOMINAL',
'ETAPA_M3', 'SEXO_M', 'TIPO DE CREDITO_Other', 'TIPO DE CREDITO_C',
'SEXO_invalido', 'TIPO DE CREDITO_V'], axis = 1).values
X = StandardScaler().fit_transform(cluster_new)
CONSTRUCCION DE OBJETO KMEANS:
from sklearn.cluster import KMeans
wcss = []
for i in range(1,11):
kmeans = KMeans(n_clusters= i, max_iter = 300,
init='k-means++', random_state=123,
algorithm='auto')
kmeans.fit(X)
wcss.append(kmeans.inertia_)
## KMeans(n_clusters=1, random_state=123)
## KMeans(n_clusters=2, random_state=123)
## KMeans(n_clusters=3, random_state=123)
## KMeans(n_clusters=4, random_state=123)
## KMeans(n_clusters=5, random_state=123)
## KMeans(n_clusters=6, random_state=123)
## KMeans(n_clusters=7, random_state=123)
## KMeans(random_state=123)
## KMeans(n_clusters=9, random_state=123)
## KMeans(n_clusters=10, random_state=123)
DESPLIEGUE: ELBOW METHOD:
plt.legend('')
plt.plot(range(1,11), wcss)
plt.title('Elbow Method')
plt.xlabel('No. of clusters')
plt.ylabel('wcss')
plt.show()
kmeans_model = KMeans(n_clusters= 4, max_iter = 300,
init='k-means++', random_state=123,
verbose = 0, algorithm='auto')
cluster['y_pred'] = kmeans_model.fit_predict(X)
De acuerdo con los resultados, seleccionamos 2 clusters de clientes (region apartir de donde los errores ya no caen drasticamente)
REDUCCION DE VARIABLES MEDIANTE PCA:
from sklearn.decomposition import PCA
pca = PCA(n_components = 2)
pca_fuga = pca.fit_transform(cluster)
pca_fuga_df = pd.DataFrame(data = pca_fuga, columns = ['Componente_1', 'Componente_2'])
pca_nombres_fuga = pd.concat([pca_fuga_df, cluster[['y_pred']]], axis = 1)
plt.figure(figsize=(10,6))
plot_clusters = sns.scatterplot(x=pca_nombres_fuga.Componente_1,
y=pca_nombres_fuga.Componente_2, hue=pca_nombres_fuga.y_pred,
palette='Set1', s=100, alpha=0.2,
data=pca_nombres_fuga).set_title('KMeans Clusters (4)', fontsize=15)
plt.show()
Luego de analizar todas las variables proporcionadas, no fue posible encontrar una región muy remarcada que permita separar a los clientes utilizando el método de K-Means. Convendría analizar a futuro otro método que permita lograr una separación más eficiente.